指定したフィールドのいずれかが空ではないという相関チェックをBean Validationで実装する
こんにちは。サービスグループの武田です。
現在携わっているプロジェクトではJava(とSprint Framework
)を利用して開発をしています。今回はその中で相関チェックを行うカスタムバリデーションの実装方法について調べたので、調査したことを残しておきます。
指定したフィールドのいずれかが空ではないことを検証するバリデーション
現在のプロジェクトではSpring
を利用してWebアプリケーションを開発しています。Webアプリケーションに限った話ではありませんが、外部から入力として値を受け取る場合、システムが許容している値かをチェックしなければいけません。この値のチェックのことをバリデーションと呼びますが、得てして複雑(かつ機能とは無関係)になりがちです。
そこで何らかのライブラリなどを使用するわけですが、Spring
ではBean Validation
との連携が簡単で便利です。Bean Validation
を使うと、アノテーションで宣言的にバリデーションを行うことができます。
実際に利用する場合は、Bean Validation
のリファレンス実装でもあるHibernate Validator
がよく使われます。このライブラリが提供しているビルトインアノテーションは公式ドキュメントを参考にしてください。
Hibernate Validator 6.0.9.Final - JSR 380 Reference Implementation: Reference Guide
用意されているアノテーションでだいたいの用は足りますが、そうでない場合は自作することになります。今回は 指定したフィールドのいずれかが空ではない 、つまり最低でもひとつのフィールドに値が入っていることを検証するカスタムアノテーションを作りました。
環境
- OS
- macOS High Sierra
- IDE
- IntelliJ IDEA 2018.1 (Ultimate Edition)
- Java
- 9
- Spring Boot
- 2.0.1
- Hibernate Validator
- 6.0.9.Final
- Lombok
- 1.16.20
- JUnit
- 4.12
プロジェクトのひな型作成
IntelliJではSpring Initializr
を使ったSpring Bootプロジェクトの作成ウィザードがあるため、それを利用します。
ウィザードの途中でプロジェクトタイプや依存ライブラリの選択ができます。
ウィザードが完了するとひな型となるプロジェクトが作成されます。今回はプロジェクトタイプとしてGradle Project
を選択したため、次のようなbuild.gradle
が作成されていました。
buildscript { ext { springBootVersion = '2.0.1.RELEASE' } repositories { mavenCentral() } dependencies { classpath("org.springframework.boot:spring-boot-gradle-plugin:${springBootVersion}") } } apply plugin: 'java' apply plugin: 'eclipse' apply plugin: 'org.springframework.boot' apply plugin: 'io.spring.dependency-management' group = 'com.example' version = '0.0.1-SNAPSHOT' sourceCompatibility = 9 repositories { mavenCentral() } dependencies { compile('org.springframework.boot:spring-boot-starter-validation') compileOnly('org.projectlombok:lombok') testCompile('org.springframework.boot:spring-boot-starter-test') }
テストケース
今回やりたいことを明確にするため、先にテストケースを見ておきます。
package com.example.demo; import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.test.context.junit4.SpringRunner; import javax.validation.ConstraintViolation; import javax.validation.Validation; import javax.validation.Validator; import javax.validation.ValidatorFactory; import javax.validation.constraints.NotNull; import javax.validation.constraints.Pattern; import java.lang.annotation.Annotation; import java.util.Set; import static org.hamcrest.MatcherAssert.*; import static org.hamcrest.Matchers.*; @RunWith(SpringRunner.class) @SpringBootTest public class DemoApplicationTests { private User user; private Validator validator; private static final String LEGAL_NAME = "test name"; private static final String LEGAL_PHONE_NUMBER = "0311112222"; private static final String LEGAL_MOBILE_NUMBER = "09011112222"; private static final String ILLEGAL_PHONE_NUMBER = "031111"; private static final String ILLEGAL_MOBILE_NUMBER = "0901111"; @Before public void before() { user = new User(); ValidatorFactory validatorFactory = Validation.buildDefaultValidatorFactory(); validator = validatorFactory.getValidator(); } @Test public void nameMustNotBeNull() { user.setPhoneNumber(LEGAL_PHONE_NUMBER); user.setMobileNumber(LEGAL_MOBILE_NUMBER); Set<ConstraintViolation<User>> violations = validator.validate(user); assertThat(violations.size(), is(1)); assertThat(getAnnotation(violations, "name"), is(instanceOf(NotNull.class))); } @Test public void bothPhoneNumberAndMobileNumberEmpty() { user.setName(LEGAL_NAME); Set<ConstraintViolation<User>> violations = validator.validate(user); assertThat(violations.size(), is(1)); assertThat(getAnnotation(violations, ""), is(instanceOf(NotEmptyAny.class))); } @Test public void eitherPhoneNumberOrMobileNumberNotEmpty() { Set<ConstraintViolation<User>> violations; user.setName(LEGAL_NAME); // phoneNumberのみ user.setPhoneNumber(LEGAL_PHONE_NUMBER); violations = validator.validate(user); assertThat(violations.size(), is(0)); // mobileNumberのみ user.setPhoneNumber(null); user.setMobileNumber(LEGAL_MOBILE_NUMBER); violations = validator.validate(user); assertThat(violations.size(), is(0)); // phoneNumberとmobileNumber user.setPhoneNumber(LEGAL_PHONE_NUMBER); violations = validator.validate(user); assertThat(violations.size(), is(0)); } @Test public void testIllegalNumber() { user.setName(LEGAL_NAME); user.setPhoneNumber(ILLEGAL_PHONE_NUMBER); user.setMobileNumber(ILLEGAL_MOBILE_NUMBER); Set<ConstraintViolation<User>> violations = validator.validate(user); assertThat(violations.size(), is(2)); assertThat(getAnnotation(violations, "phoneNumber"), is(instanceOf(Pattern.class))); assertThat(getAnnotation(violations, "mobileNumber"), is(instanceOf(Pattern.class))); } private Annotation getAnnotation(Set<ConstraintViolation<User>> violations, String path) { return violations.stream() .filter(cv -> cv.getPropertyPath().toString().equals(path)) .findFirst() .map(cv -> cv.getConstraintDescriptor().getAnnotation()) .get(); } }
User
クラスはこれから作成するクラスで、FormBean(あるいはEntity)に該当するクラスです。User
クラスはname
、phoneNumber
、mobileNumber
というフィールドを持ちます。名前、電話番号、携帯番号のイメージです。name
は必須、phoneNumber
とmobileNumber
は 両方とも空 はNGで、どちらか一方または両方入力されていればOKです。また番号は(入力されていれば)それぞれ10桁の数字列、11桁の数字列である必要があります。
Userクラスの作成
続いて、アノテーションを付与するクラスを作成します。今回はUser
クラスですね。またLombok
を利用することで紋切り型のアクセサーなどの宣言を省略します。
package com.example.demo; import lombok.Data; import javax.validation.constraints.NotNull; import javax.validation.constraints.Pattern; @Data public class User { @NotNull private String name; @Pattern(regexp = "[0-9]{10}") private String phoneNumber; @Pattern(regexp = "[0-9]{11}") private String mobileNumber; }
とてもシンプルですね!@Data
のアノテーションはLombok
のもので、自動的にフィールドのアクセサーを実装してくれたり、hashCode
やequals
メソッドなどをオーバーライドしてくれます。詳しくは公式ドキュメントを参照してください。
またコンパイルエラーを直すためにNotEmptyAny
を形だけ作ります。
package com.example.demo; public @interface NotEmptyAny { }
それでは一度ユニットテストを走らせてみます。
bothPhoneNumberAndMobileNumberEmpty
だけ失敗しています。番号が両方とも空の場合エラーがあるはずですが、現在はそのチェックを行なっていません。次はこのテストを通すために実装していきます。
NotEmptyAnyアノテーションの実装
今回は次のように実装してみました。
package com.example.demo; import org.springframework.beans.BeanWrapper; import org.springframework.beans.BeanWrapperImpl; import javax.validation.Constraint; import javax.validation.ConstraintValidator; import javax.validation.ConstraintValidatorContext; import javax.validation.Payload; import java.lang.annotation.Documented; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; import java.util.stream.Stream; @Documented @Target({ElementType.TYPE, ElementType.ANNOTATION_TYPE}) @Retention(RetentionPolicy.RUNTIME) @Constraint(validatedBy = NotEmptyAny.NotEmptyAnyValidator.class) public @interface NotEmptyAny { public String message() default "One of {fields} must not be empty"; Class<?>[] groups() default {}; Class<? extends Payload>[] payload() default {}; String[] fields(); class NotEmptyAnyValidator implements ConstraintValidator<NotEmptyAny, Object> { private String[] fields; @Override public void initialize(NotEmptyAny constraintAnnotation) { fields = constraintAnnotation.fields(); } @Override public boolean isValid(Object bean, ConstraintValidatorContext context) { if (bean == null) { return true; } BeanWrapper beanWrapper = new BeanWrapperImpl(bean); return Stream.of(fields) .map(beanWrapper::getPropertyValue) .filter(v -> v != null) .map(Object::toString) .anyMatch(v -> !v.isEmpty()); } } }
簡単にポイントをピックアップします。
- 18行目
- 個別のフィールドではなく相関チェックを行うため、クラスに付与させます
@Target
はこのアノテーションを付与できる対象を宣言するものです
- 28行目
- チェック対象のフィールド名を
fields
で受け取ります - 実際の受け取るコードは35行目です
- チェック対象のフィールド名を
- 44行目
- チェック対象のフィールドの値を取得するため
BeanWrapper
を利用します - 46行目の
getPropertyValue
で値を取得しています
- チェック対象のフィールドの値を取得するため
再テスト
最後にUser
クラスを、NotEmptyAny
を使用するように修正します。
@Data @NotEmptyAny(fields = {"phoneNumber", "mobileNumber"}) public class User {
phoneNumber
かmobileNumber
のどちらかが入力されていればOKとしたいので、@NotEmptyAny
のfields
で宣言します。
アノテーションを付与したら再度テストを実行してみます。
無事にすべてのテストをパスしました!
まとめ
今回は 指定したフィールドのいずれかが空ではない ことを検証するバリデーションを作成してみました。はじめて相関バリデーションを作りましたが、個別のフィールドではなく複数のフィールドの関係に基づいてチェックするため、通常よりやや複雑ですね。
またJavaは9を使用しましたが、最初は10でやろうとしました。ところがLombok
が10に対応していないようでエラーが出てしまいました。
Lombok fails with JDK 10 · Issue #1572 · rzwitserloot/lombok
このあたりの事情もウォッチしていきたいですね。